Skip to content

Automated Test: topic-email-management #333

Conversation

admin-coderabbit
Copy link
Owner

@admin-coderabbit admin-coderabbit commented Feb 4, 2026

This pull request was automatically created by @coderabbitai/e2e-reviewer.

Batch created pull request.

Summary by CodeRabbit

  • New Features

    • Added topic unsubscribe page with notification controls
    • Unsubscribe links now included in notification emails for quick access
  • Bug Fixes

    • Fixed dropdown button to prevent rendering undefined titles
  • Refactor

    • Code formatting and syntax modernization updates
  • Localization

    • Added translation strings for unsubscribe messages and related UI text

@coderabbit-eval
Copy link

coderabbit-eval bot commented Feb 4, 2026

📝 Walkthrough

Walkthrough

This pull request introduces a topic unsubscribe feature enabling users to stop receiving notifications for specific topics. Changes span frontend Ember.js routes, controllers, views, and templates; backend Rails routes and controller actions; email system integration with unsubscribe links; internationalization strings; and styling.

Changes

Cohort / File(s) Summary
Topic Unsubscribe Route & Frontend UI
app/assets/javascripts/discourse/routes/app-route-map.js.es6, app/assets/javascripts/discourse/routes/topic-unsubscribe.js.es6, app/assets/javascripts/discourse/controllers/topic-unsubscribe.js.es6, app/assets/javascripts/discourse/views/topic-unsubscribe.js.es6, app/assets/javascripts/discourse/templates/topic/unsubscribe.hbs
New Ember.js route, controller with computed property for notification text, view with CSS class, and template rendering unsubscribe UI with topic notification button.
Topic Unsubscribe Backend Routes & Actions
config/routes.rb, app/controllers/topics_controller.rb, app/models/topic.rb
Added Rails routes for unsubscribe actions (with and without slug), new TopicsController#unsubscribe action adjusting notification levels, and Topic#unsubscribe_url convenience method.
Email & Notification System
app/mailers/user_notifications.rb, app/views/email/notification.html.erb, lib/email/message_builder.rb, spec/components/email/message_builder_spec.rb
Integrated unsubscribe URLs into email notifications, refactored email template structure for footer sections, updated message builder to accept and handle unsubscribe_url parameter, and added test coverage.
Styling
app/assets/stylesheets/common/base/topic.scss
Added .topic-unsubscribe styles with nested notification options layout adjustments.
Internationalization
config/locales/client.en.yml, config/locales/server.en.yml
Added unsubscribe translation keys for UI text and email footer, removed obsolete topic.likes translations, updated unsubscribe_link to multi-line format.
Frontend Refactoring & Fixes
app/assets/javascripts/discourse/routes/topic-from-params.js.es6, app/assets/javascripts/discourse/components/dropdown-button.js.es6
Converted topic-from-params route hooks to ES6 shorthand methods with local constants; fixed dropdown-button to conditionally render title only when present.
Code Quality Updates
app/models/topic_user.rb, lib/email/message_builder.rb
Minor whitespace formatting and syntax cleanup preserving all behavioral semantics.

Sequence Diagram

sequenceDiagram
    participant User
    participant Frontend as Frontend Route
    participant Backend as TopicsController
    participant Model as Topic Model
    participant EmailSystem as Email System

    User->>Frontend: Visit /t/:slug/:id/unsubscribe
    Frontend->>Backend: GET unsubscribe action
    Backend->>Model: Load topic & notification level
    Model-->>Backend: Topic loaded
    Backend->>Model: Update notification_level to :muted
    Model-->>Backend: Changes saved
    Backend-->>Frontend: Render unsubscribe template
    Frontend-->>User: Display unsubscribe confirmation UI
    
    Note over EmailSystem: Subsequent emails include unsubscribe link
    EmailSystem->>EmailSystem: Use topic.unsubscribe_url in email footer
    EmailSystem-->>User: Send notification with unsubscribe option
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 A button springs to life so bright,
Unsubscribe with one swift click,
Topics quiet, peace takes flight,
Email links work swift and quick!

🚥 Pre-merge checks | ✅ 1 | ❌ 1
❌ Failed checks (1 inconclusive)
Check name Status Explanation Resolution
Title check ❓ Inconclusive The title 'Automated Test: topic-email-management' is vague and generic, using non-descriptive terminology that does not convey the actual scope of changes to someone reviewing pull request history. Replace with a descriptive title that clearly summarizes the main feature, such as 'Add topic unsubscribe feature with email notification management' or 'Implement topic notification unsubscribe endpoint and UI'.
✅ Passed checks (1 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch topic-email-management

Comment @coderabbit eval help to get the list of available commands and usage tips.

@coderabbit-eval coderabbit-eval bot closed this Feb 4, 2026
Copy link

@coderabbit-eval coderabbit-eval bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
app/models/topic_user.rb (1)

120-125: ⚠️ Potential issue | 🟠 Major

Pre-existing bug on line 122: user_id is assigned topic instead of user.

Line 122 reads user_id = user.is_a?(User) ? user.id : topic — when user is already an integer ID, this incorrectly assigns the topic variable to user_id. It should be : user. This is not introduced by this PR but is adjacent to the changed line 125 and could cause data corruption in track_visit!.

🐛 Suggested fix
-      user_id = user.is_a?(User) ? user.id : topic
+      user_id = user.is_a?(User) ? user.id : user
app/assets/javascripts/discourse/routes/app-route-map.js.es6 (1)

8-13: ⚠️ Potential issue | 🟠 Major

Route conflict: topicUnsubscribe cannot be reached due to topic.fromParamsNear shadowing.

In Ember.js, the URL /t/:slug/:id/unsubscribe matches the topic resource first, then attempts to match the remaining /unsubscribe segment against its child routes. Since fromParamsNear has the pattern /:nearPost, it will match /unsubscribe with nearPost="unsubscribe", preventing the top-level topicUnsubscribe route from ever being reached.

To fix this, either:

  1. Move topicUnsubscribe as a child of the topic resource (preferred for consistency with topic-related routes), or
  2. Use a more specific static path pattern instead of a dynamic segment for fromParamsNear to avoid the conflict.
app/controllers/topics_controller.rb (1)

8-28: ⚠️ Potential issue | 🟡 Minor

Add trailing comma to satisfy style check.

RuboCop flags the missing trailing comma in the multiline array.

Proposed fix
-                                          :unsubscribe]
+                                          :unsubscribe,]
🤖 Fix all issues with AI agents
In `@app/assets/javascripts/discourse/controllers/topic-unsubscribe.js.es6`:
- Around line 5-7: Rename the computed property stopNotificiationsText to the
correct stopNotificationsText in the controller (update the function name and
its .property declaration) and update any template bindings that reference the
misspelled name (e.g., the unsubscribe template's use of stopNotificiationsText)
to use stopNotificationsText so the property name matches English and remains
consistent across controller and template.

In `@app/controllers/topics_controller.rb`:
- Around line 98-115: The unsubscribe action currently mutates TopicUser state
on a GET which is unsafe; change it to only accept non-GET side-effectful
requests by updating the controller to require a POST/DELETE (or verify a signed
token) before changing TopicUser.notification_level: keep TopicView and the
lookup (TopicView.new and TopicUser.find_by) but gate the mutation behind a
request.post?/request.delete? check or validate a signed param/token, and ensure
CSRF protection is enabled; after the mutation still call tu.save! and
perform_show_response for the POST flow, and for GET requests only perform a
safe redirect to `@topic_view.topic.unsubscribe_url` without changing tu.
- Around line 105-113: Currently the code assumes TopicUser.find_by returns a
record and dereferences tu.notification_level, which raises NoMethodError for
users without a TopicUser; replace the find_by call with
TopicUser.find_or_initialize_by(user_id: current_user.id, topic_id:
params[:topic_id]) (or use a TopicUser.change helper) to ensure tu is present,
then compute the target level using TopicUser.notification_levels[:regular] and
[:muted] and assign tu.notification_level accordingly before calling tu.save!;
update the method to use the tu variable and ensure it handles both new and
existing records.

In `@app/views/email/notification.html.erb`:
- Line 10: The h4 element uses an incorrect class attribute value
"class='.previous-discussion'" so the leading dot prevents CSS matching; update
the attribute on the h4 (the element with the text t
"user_notifications.previous_discussion") to remove the leading period (use
"previous-discussion" as the class value) so the class matches your stylesheet.
- Around line 5-9: The footer's respond_instructions are being duplicated
because the div "<div class='footer'>%{respond_instructions}</div>" is rendered
inside the context_posts conditional and again elsewhere; remove that inner
footer from the context_posts block (or change the conditional to render
respond_instructions only in the outer/footer area) so "%{respond_instructions}"
appears exactly once in app/views/email/notification.html.erb.
🧹 Nitpick comments (5)
app/assets/javascripts/discourse/routes/topic-unsubscribe.js.es6 (3)

4-9: No error handling for loadTopicView — user sees a generic error on failure.

If the topic doesn't exist or the network request fails, the rejected promise propagates to the default Ember route error handler. For an unsubscribe page (often reached from email links), showing a friendly error message is important.

💡 Suggested improvement
   model(params) {
     const topic = this.store.createRecord("topic", { id: params.id });
     return PostStream.loadTopicView(params.id).then(json => {
       topic.updateFromJson(json);
       return topic;
+    }).catch(() => {
+      return Ember.RSVP.reject({ not_found: true });
     });
   },

Alternatively, implement an error action in the route to render a user-friendly message for unsubscribe failures.


12-15: afterModel assumes topic.details is populated — add a guard.

If updateFromJson fails to populate details (e.g., unexpected JSON shape), this will throw. A simple guard avoids a runtime error on the unsubscribe page.

🛡️ Defensive check
   afterModel(topic) {
     // hide the notification reason text
-    topic.set("details.notificationReasonText", null);
+    if (topic.get("details")) {
+      topic.set("details.notificationReasonText", null);
+    }
   },

1-3: Discourse.Route is used as a global while PostStream is explicitly imported.

For consistency with the import PostStream on Line 1, consider importing the Route base class as well instead of relying on the Discourse global. This is a minor consistency nit in the context of a legacy codebase.

app/assets/javascripts/discourse/routes/topic-from-params.js.es6 (1)

11-16: Partial ES6 modernization: self = this pattern can be eliminated with arrow functions.

The varconst upgrade is good, but const self = this (Line 11) is still needed only because Line 23 uses function(). Converting to an arrow function would complete the modernization and remove the self alias.

♻️ Suggested change
-    const self = this,
-          topic = this.modelFor('topic'),
+    const topic = this.modelFor('topic'),
           postStream = topic.get('postStream'),
           topicController = this.controllerFor('topic'),
           topicProgressController = this.controllerFor('topic-progress'),
           composerController = this.controllerFor('composer');
     ...
-    postStream.refresh(params).then(function () {
+    postStream.refresh(params).then(() => {
       ...
       Ember.run.scheduleOnce('afterRender', function() {
-        self.appEvents.trigger('post:highlight', closest);
+        this.appEvents.trigger('post:highlight', closest);
       });

Also applies to: 23-23

app/assets/javascripts/discourse/controllers/topic-unsubscribe.js.es6 (1)

1-3: Minor version correction needed in deprecation timeline: ObjectController was deprecated in Ember 1.11 (not 1.13) and removed in Ember 2.0.

Note that this codebase uses a custom ObjectController wrapper defined in app/assets/javascripts/discourse/controllers/object.js.es6 which extends Ember.ObjectController. If modernizing, replace with Ember.Controller and explicitly reference model properties rather than relying on proxying behavior. This pattern is used extensively across 40+ controllers in the codebase, so refactoring should be systematic if undertaken.

Comment on lines +5 to +7
stopNotificiationsText: function() {
return I18n.t("topic.unsubscribe.stop_notifications", { title: this.get("model.fancyTitle") });
}.property("model.fancyTitle"),

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

rg -n 'stopNotifi' --type-list | head -20

Repository: admin-coderabbit/coderabbit-discourse-mirror

Length of output: 477


🏁 Script executed:

rg -in 'stopNotifi' app/ --type-list

Repository: admin-coderabbit/coderabbit-discourse-mirror

Length of output: 477


🏁 Script executed:

# Search for the property name in both JS and template files
find app -type f \( -name "*.es6" -o -name "*.js" -o -name "*.hbs" \) | xargs rg -l 'stopNotifi' 2>/dev/null

Repository: admin-coderabbit/coderabbit-discourse-mirror

Length of output: 222


🏁 Script executed:

# More targeted search - look for the exact property and its usage
rg -n 'stopNotifi' app/assets/javascripts/ app/assets/templates/ 2>/dev/null | head -50

Repository: admin-coderabbit/coderabbit-discourse-mirror

Length of output: 298


🏁 Script executed:

# Search for the correctly spelled variant
rg -n 'stopNotifications' app/ --type-list 2>/dev/null

Repository: admin-coderabbit/coderabbit-discourse-mirror

Length of output: 70


🏁 Script executed:

# Check if there are any other references or usages of this property
rg -n 'unsubscribe' app/assets/javascripts/discourse/controllers/ app/assets/javascripts/discourse/templates/ 2>/dev/null

Repository: admin-coderabbit/coderabbit-discourse-mirror

Length of output: 950


🏁 Script executed:

# Look for any imports or references to topic-unsubscribe controller elsewhere
rg -n 'topic-unsubscribe' app/ 2>/dev/null

Repository: admin-coderabbit/coderabbit-discourse-mirror

Length of output: 259


Typo in computed property name: stopNotificiationsTextstopNotificationsText.

The property name has an extra iNotificiations instead of Notifications. Although the template also uses the same misspelled name (so the binding works), this creates confusion for any future developer and doesn't match the actual English word. Fix both the property definition and template reference.

✏️ Proposed fix
-  stopNotificiationsText: function() {
+  stopNotificationsText: function() {
     return I18n.t("topic.unsubscribe.stop_notifications", { title: this.get("model.fancyTitle") });
   }.property("model.fancyTitle"),

Also update app/assets/javascripts/discourse/templates/topic/unsubscribe.hbs line 3:

-    {{{stopNotificiationsText}}}
+    {{{stopNotificationsText}}}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
stopNotificiationsText: function() {
return I18n.t("topic.unsubscribe.stop_notifications", { title: this.get("model.fancyTitle") });
}.property("model.fancyTitle"),
stopNotificationsText: function() {
return I18n.t("topic.unsubscribe.stop_notifications", { title: this.get("model.fancyTitle") });
}.property("model.fancyTitle"),
🤖 Prompt for AI Agents
In `@app/assets/javascripts/discourse/controllers/topic-unsubscribe.js.es6` around
lines 5 - 7, Rename the computed property stopNotificiationsText to the correct
stopNotificationsText in the controller (update the function name and its
.property declaration) and update any template bindings that reference the
misspelled name (e.g., the unsubscribe template's use of stopNotificiationsText)
to use stopNotificationsText so the property name matches English and remains
consistent across controller and template.

Comment on lines +98 to +115
def unsubscribe
@topic_view = TopicView.new(params[:topic_id], current_user)

if slugs_do_not_match || (!request.format.json? && params[:slug].blank?)
return redirect_to @topic_view.topic.unsubscribe_url, status: 301
end

tu = TopicUser.find_by(user_id: current_user.id, topic_id: params[:topic_id])

if tu.notification_level > TopicUser.notification_levels[:regular]
tu.notification_level = TopicUser.notification_levels[:regular]
else
tu.notification_level = TopicUser.notification_levels[:muted]
end

tu.save!

perform_show_response

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Avoid state changes via GET in unsubscribe.

This action mutates notification settings on a GET request, which is CSRF‑prone for logged‑in users. Consider switching to POST with CSRF protection or using a signed token in the URL to permit a safe one‑click flow.

🧰 Tools
🪛 RuboCop (1.84.0)

[convention] 98-116: Assignment Branch Condition size for unsubscribe is too high. [<4, 31, 6> 31.83/23]

(Metrics/AbcSize)

🤖 Prompt for AI Agents
In `@app/controllers/topics_controller.rb` around lines 98 - 115, The unsubscribe
action currently mutates TopicUser state on a GET which is unsafe; change it to
only accept non-GET side-effectful requests by updating the controller to
require a POST/DELETE (or verify a signed token) before changing
TopicUser.notification_level: keep TopicView and the lookup (TopicView.new and
TopicUser.find_by) but gate the mutation behind a request.post?/request.delete?
check or validate a signed param/token, and ensure CSRF protection is enabled;
after the mutation still call tu.save! and perform_show_response for the POST
flow, and for GET requests only perform a safe redirect to
`@topic_view.topic.unsubscribe_url` without changing tu.

Comment on lines +105 to +113
tu = TopicUser.find_by(user_id: current_user.id, topic_id: params[:topic_id])

if tu.notification_level > TopicUser.notification_levels[:regular]
tu.notification_level = TopicUser.notification_levels[:regular]
else
tu.notification_level = TopicUser.notification_levels[:muted]
end

tu.save!

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Handle missing TopicUser rows before accessing notification_level.

TopicUser.find_by can return nil, causing a NoMethodError for users who never had a TopicUser row. Use find_or_initialize_by or TopicUser.change with a computed level so unsubscribe works for all users (and this also helps keep the method’s ABC size down).

Proposed fix
-    tu = TopicUser.find_by(user_id: current_user.id, topic_id: params[:topic_id])
-
-    if tu.notification_level > TopicUser.notification_levels[:regular]
-      tu.notification_level = TopicUser.notification_levels[:regular]
-    else
-      tu.notification_level = TopicUser.notification_levels[:muted]
-    end
-
-    tu.save!
+    tu = TopicUser.find_or_initialize_by(user_id: current_user.id, topic_id: params[:topic_id])
+    current_level = tu.notification_level || TopicUser.notification_levels[:regular]
+    new_level =
+      if current_level > TopicUser.notification_levels[:regular]
+        TopicUser.notification_levels[:regular]
+      else
+        TopicUser.notification_levels[:muted]
+      end
+
+    TopicUser.change(current_user, params[:topic_id], notification_level: new_level)
🤖 Prompt for AI Agents
In `@app/controllers/topics_controller.rb` around lines 105 - 113, Currently the
code assumes TopicUser.find_by returns a record and dereferences
tu.notification_level, which raises NoMethodError for users without a TopicUser;
replace the find_by call with TopicUser.find_or_initialize_by(user_id:
current_user.id, topic_id: params[:topic_id]) (or use a TopicUser.change helper)
to ensure tu is present, then compute the target level using
TopicUser.notification_levels[:regular] and [:muted] and assign
tu.notification_level accordingly before calling tu.save!; update the method to
use the tu variable and ensure it handles both new and existing records.

Comment on lines +5 to 9
<% if context_posts.present? %>
<div class='footer'>%{respond_instructions}</div>

<hr>

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Avoid duplicate respond instructions when context posts exist.

%{respond_instructions} is rendered inside the context_posts block and again in the footer, causing duplicate text in emails with context. Remove the inner footer.

Proposed fix
-  <% if context_posts.present? %>
-    <div class='footer'>%{respond_instructions}</div>
-
-    <hr>
+  <% if context_posts.present? %>
+    <hr>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<% if context_posts.present? %>
<div class='footer'>%{respond_instructions}</div>
<hr>
<% if context_posts.present? %>
<hr>
🤖 Prompt for AI Agents
In `@app/views/email/notification.html.erb` around lines 5 - 9, The footer's
respond_instructions are being duplicated because the div "<div
class='footer'>%{respond_instructions}</div>" is rendered inside the
context_posts conditional and again elsewhere; remove that inner footer from the
context_posts block (or change the conditional to render respond_instructions
only in the outer/footer area) so "%{respond_instructions}" appears exactly once
in app/views/email/notification.html.erb.


<% context_posts.each do |p| %>
<%= render :partial => 'email/post', :locals => {:post => p} %>
<h4 class='.previous-discussion'><%= t "user_notifications.previous_discussion" %></h4>

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Fix class attribute typo for the previous‑discussion header.

class='.previous-discussion' includes a leading dot, so the class won’t match CSS selectors.

Proposed fix
-    <h4 class='.previous-discussion'><%= t "user_notifications.previous_discussion" %></h4>
+    <h4 class='previous-discussion'><%= t "user_notifications.previous_discussion" %></h4>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<h4 class='.previous-discussion'><%= t "user_notifications.previous_discussion" %></h4>
<h4 class='previous-discussion'><%= t "user_notifications.previous_discussion" %></h4>
🤖 Prompt for AI Agents
In `@app/views/email/notification.html.erb` at line 10, The h4 element uses an
incorrect class attribute value "class='.previous-discussion'" so the leading
dot prevents CSS matching; update the attribute on the h4 (the element with the
text t "user_notifications.previous_discussion") to remove the leading period
(use "previous-discussion" as the class value) so the class matches your
stylesheet.

Sign in to join this conversation on GitHub.
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

1 participant